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/msdoc/pieces.py
ADDED
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
"""CLX/Piece Table parsing and CP-to-FC text retrieval."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
import struct
|
|
7
|
+
|
|
8
|
+
from ..binary import BinaryReader
|
|
9
|
+
from ..diagnostics import ConversionReport, SourceLocation
|
|
10
|
+
from ..errors import BinaryBoundsError, InvalidWordDocument
|
|
11
|
+
from ..model import StoryCharacter
|
|
12
|
+
from .sprm import PropertyModifier, parse_grpprl
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
_COMPRESSED_SPECIAL_CHARACTERS = {
|
|
16
|
+
0x82: "\u201a",
|
|
17
|
+
0x83: "\u0192",
|
|
18
|
+
0x84: "\u201e",
|
|
19
|
+
0x85: "\u2026",
|
|
20
|
+
0x86: "\u2020",
|
|
21
|
+
0x87: "\u2021",
|
|
22
|
+
0x88: "\u02c6",
|
|
23
|
+
0x89: "\u2030",
|
|
24
|
+
0x8A: "\u0160",
|
|
25
|
+
0x8B: "\u2039",
|
|
26
|
+
0x8C: "\u0152",
|
|
27
|
+
0x91: "\u2018",
|
|
28
|
+
0x92: "\u2019",
|
|
29
|
+
0x93: "\u201c",
|
|
30
|
+
0x94: "\u201d",
|
|
31
|
+
0x95: "\u2022",
|
|
32
|
+
0x96: "\u2013",
|
|
33
|
+
0x97: "\u2014",
|
|
34
|
+
0x98: "\u02dc",
|
|
35
|
+
0x99: "\u2122",
|
|
36
|
+
0x9A: "\u0161",
|
|
37
|
+
0x9B: "\u203a",
|
|
38
|
+
0x9C: "\u0153",
|
|
39
|
+
0x9F: "\u0178",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# Prm0 stores a seven-bit isprm instead of a full Sprm. These are the useful
|
|
44
|
+
# one-byte mappings from the MS-DOC Prm0 table; unknown values stay diagnostic.
|
|
45
|
+
_PRM0_SPRMS = {
|
|
46
|
+
0x05: 0x2461,
|
|
47
|
+
0x07: 0x2405,
|
|
48
|
+
0x08: 0x2406,
|
|
49
|
+
0x09: 0x2407,
|
|
50
|
+
0x4D: 0x2A0C,
|
|
51
|
+
0x53: 0x2A33,
|
|
52
|
+
0x55: 0x0835,
|
|
53
|
+
0x56: 0x0836,
|
|
54
|
+
0x57: 0x0837,
|
|
55
|
+
0x5A: 0x083A,
|
|
56
|
+
0x5B: 0x083B,
|
|
57
|
+
0x5C: 0x083C,
|
|
58
|
+
0x5E: 0x2A3E,
|
|
59
|
+
0x62: 0x2A42,
|
|
60
|
+
0x68: 0x2A48,
|
|
61
|
+
0x73: 0x2A53,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(slots=True, frozen=True)
|
|
66
|
+
class Piece:
|
|
67
|
+
cp_start: int
|
|
68
|
+
cp_end: int
|
|
69
|
+
file_offset: int
|
|
70
|
+
compressed: bool
|
|
71
|
+
prm: int
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def character_count(self) -> int:
|
|
75
|
+
return self.cp_end - self.cp_start
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(slots=True, frozen=True)
|
|
79
|
+
class PieceTable:
|
|
80
|
+
pieces: tuple[Piece, ...]
|
|
81
|
+
word_document_stream: bytes
|
|
82
|
+
prcs: tuple[tuple[PropertyModifier, ...], ...] = ()
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def cp_start(self) -> int:
|
|
86
|
+
return self.pieces[0].cp_start if self.pieces else 0
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def cp_end(self) -> int:
|
|
90
|
+
return self.pieces[-1].cp_end if self.pieces else 0
|
|
91
|
+
|
|
92
|
+
def extract(
|
|
93
|
+
self,
|
|
94
|
+
cp_start: int,
|
|
95
|
+
cp_end: int,
|
|
96
|
+
report: ConversionReport,
|
|
97
|
+
*,
|
|
98
|
+
story: str,
|
|
99
|
+
) -> str:
|
|
100
|
+
if cp_start < self.cp_start or cp_end < cp_start or cp_end > self.cp_end:
|
|
101
|
+
raise InvalidWordDocument(
|
|
102
|
+
f"requested CP range [{cp_start}, {cp_end}) is outside Piece Table "
|
|
103
|
+
f"range [{self.cp_start}, {self.cp_end})"
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
return "".join(
|
|
107
|
+
unit.text
|
|
108
|
+
for unit in self.extract_characters(
|
|
109
|
+
cp_start,
|
|
110
|
+
cp_end,
|
|
111
|
+
report,
|
|
112
|
+
story=story,
|
|
113
|
+
)
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
def extract_characters(
|
|
117
|
+
self,
|
|
118
|
+
cp_start: int,
|
|
119
|
+
cp_end: int,
|
|
120
|
+
report: ConversionReport,
|
|
121
|
+
*,
|
|
122
|
+
story: str,
|
|
123
|
+
) -> tuple[StoryCharacter, ...]:
|
|
124
|
+
"""Decode a CP range while retaining UTF-16 code-unit coordinates."""
|
|
125
|
+
|
|
126
|
+
if cp_start < self.cp_start or cp_end < cp_start or cp_end > self.cp_end:
|
|
127
|
+
raise InvalidWordDocument(
|
|
128
|
+
f"requested CP range [{cp_start}, {cp_end}) is outside Piece Table "
|
|
129
|
+
f"range [{self.cp_start}, {self.cp_end})"
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
characters: list[StoryCharacter] = []
|
|
133
|
+
invalid_utf16_ranges: list[tuple[int, int, int, int]] = []
|
|
134
|
+
for piece in self.pieces:
|
|
135
|
+
start = max(cp_start, piece.cp_start)
|
|
136
|
+
end = min(cp_end, piece.cp_end)
|
|
137
|
+
if start >= end:
|
|
138
|
+
continue
|
|
139
|
+
relative_start = start - piece.cp_start
|
|
140
|
+
character_count = end - start
|
|
141
|
+
bytes_per_character = 1 if piece.compressed else 2
|
|
142
|
+
byte_start = piece.file_offset + relative_start * bytes_per_character
|
|
143
|
+
byte_length = character_count * bytes_per_character
|
|
144
|
+
byte_end = byte_start + byte_length
|
|
145
|
+
if byte_start < 0 or byte_end > len(self.word_document_stream):
|
|
146
|
+
raise InvalidWordDocument(
|
|
147
|
+
f"piece CP [{piece.cp_start}, {piece.cp_end}) references "
|
|
148
|
+
f"WordDocument bytes [{byte_start}, {byte_end}) outside the stream"
|
|
149
|
+
)
|
|
150
|
+
raw = self.word_document_stream[byte_start:byte_end]
|
|
151
|
+
if piece.compressed:
|
|
152
|
+
characters.extend(
|
|
153
|
+
StoryCharacter(
|
|
154
|
+
_COMPRESSED_SPECIAL_CHARACTERS.get(value, chr(value)),
|
|
155
|
+
start + index,
|
|
156
|
+
start + index + 1,
|
|
157
|
+
)
|
|
158
|
+
for index, value in enumerate(raw)
|
|
159
|
+
)
|
|
160
|
+
else:
|
|
161
|
+
code_units = struct.unpack(f"<{len(raw) // 2}H", raw)
|
|
162
|
+
index = 0
|
|
163
|
+
while index < len(code_units):
|
|
164
|
+
code_unit = code_units[index]
|
|
165
|
+
unit_cp = start + index
|
|
166
|
+
if (
|
|
167
|
+
0xD800 <= code_unit <= 0xDBFF
|
|
168
|
+
and index + 1 < len(code_units)
|
|
169
|
+
and 0xDC00 <= code_units[index + 1] <= 0xDFFF
|
|
170
|
+
):
|
|
171
|
+
low = code_units[index + 1]
|
|
172
|
+
scalar = 0x10000 + (
|
|
173
|
+
((code_unit - 0xD800) << 10) | (low - 0xDC00)
|
|
174
|
+
)
|
|
175
|
+
characters.append(
|
|
176
|
+
StoryCharacter(chr(scalar), unit_cp, unit_cp + 2)
|
|
177
|
+
)
|
|
178
|
+
index += 2
|
|
179
|
+
continue
|
|
180
|
+
if 0xD800 <= code_unit <= 0xDFFF:
|
|
181
|
+
characters.append(
|
|
182
|
+
StoryCharacter("\uFFFD", unit_cp, unit_cp + 1)
|
|
183
|
+
)
|
|
184
|
+
invalid_utf16_ranges.append(
|
|
185
|
+
(
|
|
186
|
+
unit_cp,
|
|
187
|
+
unit_cp + 1,
|
|
188
|
+
byte_start + index * 2,
|
|
189
|
+
byte_start + index * 2 + 2,
|
|
190
|
+
)
|
|
191
|
+
)
|
|
192
|
+
else:
|
|
193
|
+
characters.append(
|
|
194
|
+
StoryCharacter(chr(code_unit), unit_cp, unit_cp + 1)
|
|
195
|
+
)
|
|
196
|
+
index += 1
|
|
197
|
+
|
|
198
|
+
for invalid_cp_start, invalid_cp_end, invalid_fc_start, invalid_fc_end in (
|
|
199
|
+
invalid_utf16_ranges
|
|
200
|
+
):
|
|
201
|
+
report.warning(
|
|
202
|
+
"INVALID_UTF16",
|
|
203
|
+
"invalid UTF-16LE code unit was replaced while reading text",
|
|
204
|
+
location=SourceLocation(
|
|
205
|
+
story=story,
|
|
206
|
+
cp_start=invalid_cp_start,
|
|
207
|
+
cp_end=invalid_cp_end,
|
|
208
|
+
stream="WordDocument",
|
|
209
|
+
fc_start=invalid_fc_start,
|
|
210
|
+
fc_end=invalid_fc_end,
|
|
211
|
+
),
|
|
212
|
+
)
|
|
213
|
+
return tuple(characters)
|
|
214
|
+
|
|
215
|
+
def fc_range_to_cp_ranges(
|
|
216
|
+
self,
|
|
217
|
+
fc_start: int,
|
|
218
|
+
fc_end: int,
|
|
219
|
+
) -> tuple[tuple[int, int], ...]:
|
|
220
|
+
"""Intersect a physical FC range with pieces and return CP ranges."""
|
|
221
|
+
|
|
222
|
+
if fc_start < 0 or fc_end < fc_start:
|
|
223
|
+
raise InvalidWordDocument(
|
|
224
|
+
f"invalid FC range [{fc_start}, {fc_end})"
|
|
225
|
+
)
|
|
226
|
+
ranges: list[tuple[int, int]] = []
|
|
227
|
+
for piece in self.pieces:
|
|
228
|
+
width = 1 if piece.compressed else 2
|
|
229
|
+
piece_fc_start = piece.file_offset
|
|
230
|
+
piece_fc_end = piece.file_offset + piece.character_count * width
|
|
231
|
+
start = max(fc_start, piece_fc_start)
|
|
232
|
+
end = min(fc_end, piece_fc_end)
|
|
233
|
+
if start >= end:
|
|
234
|
+
continue
|
|
235
|
+
if not piece.compressed and (
|
|
236
|
+
(start - piece_fc_start) % 2 or (end - piece_fc_start) % 2
|
|
237
|
+
):
|
|
238
|
+
raise InvalidWordDocument(
|
|
239
|
+
f"FKP FC range [{fc_start}, {fc_end}) splits a UTF-16 code unit"
|
|
240
|
+
)
|
|
241
|
+
cp_range_start = piece.cp_start + (start - piece_fc_start) // width
|
|
242
|
+
cp_range_end = piece.cp_start + (end - piece_fc_start) // width
|
|
243
|
+
ranges.append((cp_range_start, cp_range_end))
|
|
244
|
+
return tuple(ranges)
|
|
245
|
+
|
|
246
|
+
def modifiers_for_piece(
|
|
247
|
+
self,
|
|
248
|
+
piece: Piece,
|
|
249
|
+
*,
|
|
250
|
+
property_group: int,
|
|
251
|
+
) -> tuple[PropertyModifier, ...]:
|
|
252
|
+
"""Resolve a PCD Prm and retain only modifiers for one sgc group."""
|
|
253
|
+
|
|
254
|
+
if piece.prm == 0:
|
|
255
|
+
return ()
|
|
256
|
+
if piece.prm & 1:
|
|
257
|
+
index = piece.prm >> 1
|
|
258
|
+
if index >= len(self.prcs):
|
|
259
|
+
raise InvalidWordDocument(
|
|
260
|
+
f"piece Prm references PRC {index}, but CLX has {len(self.prcs)}"
|
|
261
|
+
)
|
|
262
|
+
modifiers = self.prcs[index]
|
|
263
|
+
else:
|
|
264
|
+
isprm = (piece.prm >> 1) & 0x7F
|
|
265
|
+
opcode = _PRM0_SPRMS.get(isprm)
|
|
266
|
+
if opcode is None:
|
|
267
|
+
return ()
|
|
268
|
+
modifiers = (PropertyModifier(opcode, bytes((piece.prm >> 8,))),)
|
|
269
|
+
return tuple(
|
|
270
|
+
modifier
|
|
271
|
+
for modifier in modifiers
|
|
272
|
+
if ((modifier.opcode >> 10) & 0x07) == property_group
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _parse_plc_pcd(
|
|
277
|
+
data: bytes,
|
|
278
|
+
word_document_stream: bytes,
|
|
279
|
+
prcs: tuple[tuple[PropertyModifier, ...], ...],
|
|
280
|
+
) -> PieceTable:
|
|
281
|
+
if len(data) < 4 or (len(data) - 4) % 12:
|
|
282
|
+
raise InvalidWordDocument(
|
|
283
|
+
f"PlcPcd size {len(data)} does not match the 12*n+4 layout"
|
|
284
|
+
)
|
|
285
|
+
piece_count = (len(data) - 4) // 12
|
|
286
|
+
if piece_count == 0:
|
|
287
|
+
raise InvalidWordDocument("PlcPcd contains no pieces")
|
|
288
|
+
|
|
289
|
+
cp_count = piece_count + 1
|
|
290
|
+
cps = struct.unpack_from(f"<{cp_count}I", data, 0)
|
|
291
|
+
if cps[0] != 0:
|
|
292
|
+
raise InvalidWordDocument(f"Piece Table starts at CP {cps[0]}, expected 0")
|
|
293
|
+
for previous, current in zip(cps, cps[1:]):
|
|
294
|
+
if current <= previous or current >= 0x7FFFFFFF:
|
|
295
|
+
raise InvalidWordDocument(
|
|
296
|
+
"Piece Table CP values must be strictly increasing and valid"
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
pieces: list[Piece] = []
|
|
300
|
+
pcd_offset = cp_count * 4
|
|
301
|
+
for index in range(piece_count):
|
|
302
|
+
record = data[pcd_offset + index * 8 : pcd_offset + (index + 1) * 8]
|
|
303
|
+
raw_fc = struct.unpack_from("<I", record, 2)[0]
|
|
304
|
+
prm = struct.unpack_from("<H", record, 6)[0]
|
|
305
|
+
compressed = bool(raw_fc & 0x40000000)
|
|
306
|
+
if raw_fc & 0x80000000:
|
|
307
|
+
raise InvalidWordDocument("FcCompressed reserved bit is set")
|
|
308
|
+
encoded_fc = raw_fc & 0x3FFFFFFF
|
|
309
|
+
if compressed:
|
|
310
|
+
if encoded_fc & 1:
|
|
311
|
+
raise InvalidWordDocument("compressed piece has an odd encoded FC")
|
|
312
|
+
file_offset = encoded_fc // 2
|
|
313
|
+
else:
|
|
314
|
+
file_offset = encoded_fc
|
|
315
|
+
pieces.append(
|
|
316
|
+
Piece(
|
|
317
|
+
cp_start=cps[index],
|
|
318
|
+
cp_end=cps[index + 1],
|
|
319
|
+
file_offset=file_offset,
|
|
320
|
+
compressed=compressed,
|
|
321
|
+
prm=prm,
|
|
322
|
+
)
|
|
323
|
+
)
|
|
324
|
+
return PieceTable(tuple(pieces), word_document_stream, prcs)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def read_piece_table(
|
|
328
|
+
table_stream: bytes,
|
|
329
|
+
word_document_stream: bytes,
|
|
330
|
+
*,
|
|
331
|
+
fc_clx: int,
|
|
332
|
+
lcb_clx: int,
|
|
333
|
+
report: ConversionReport,
|
|
334
|
+
) -> PieceTable:
|
|
335
|
+
if lcb_clx < 5:
|
|
336
|
+
raise InvalidWordDocument(f"CLX is too small ({lcb_clx} bytes)")
|
|
337
|
+
if fc_clx > len(table_stream) - lcb_clx:
|
|
338
|
+
raise InvalidWordDocument(
|
|
339
|
+
f"CLX range [{fc_clx}, {fc_clx + lcb_clx}) exceeds Table stream"
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
reader = BinaryReader(
|
|
343
|
+
table_stream[fc_clx : fc_clx + lcb_clx],
|
|
344
|
+
label="CLX",
|
|
345
|
+
base_offset=fc_clx,
|
|
346
|
+
)
|
|
347
|
+
prcs: list[tuple[PropertyModifier, ...]] = []
|
|
348
|
+
try:
|
|
349
|
+
while reader.remaining:
|
|
350
|
+
clxt = reader.u8()
|
|
351
|
+
if clxt == 0x01:
|
|
352
|
+
grpprl_size = reader.u16()
|
|
353
|
+
if grpprl_size > 0x3FA2:
|
|
354
|
+
raise InvalidWordDocument(
|
|
355
|
+
f"CLX PRC has invalid grpprl size {grpprl_size}"
|
|
356
|
+
)
|
|
357
|
+
grpprl = reader.read(grpprl_size)
|
|
358
|
+
prcs.append(
|
|
359
|
+
parse_grpprl(
|
|
360
|
+
grpprl,
|
|
361
|
+
label=f"CLX.Prc[{len(prcs)}].grpprl",
|
|
362
|
+
)
|
|
363
|
+
)
|
|
364
|
+
continue
|
|
365
|
+
if clxt == 0x02:
|
|
366
|
+
plc_size = reader.u32()
|
|
367
|
+
plc_data = reader.read(plc_size)
|
|
368
|
+
if reader.remaining:
|
|
369
|
+
report.warning(
|
|
370
|
+
"CLX_TRAILING_DATA",
|
|
371
|
+
"bytes after the Pcdt in CLX were ignored",
|
|
372
|
+
byte_count=reader.remaining,
|
|
373
|
+
)
|
|
374
|
+
piece_table = _parse_plc_pcd(
|
|
375
|
+
plc_data,
|
|
376
|
+
word_document_stream,
|
|
377
|
+
tuple(prcs),
|
|
378
|
+
)
|
|
379
|
+
unknown_prm0 = sorted(
|
|
380
|
+
{
|
|
381
|
+
(piece.prm >> 1) & 0x7F
|
|
382
|
+
for piece in piece_table.pieces
|
|
383
|
+
if piece.prm
|
|
384
|
+
and not piece.prm & 1
|
|
385
|
+
and ((piece.prm >> 1) & 0x7F) not in _PRM0_SPRMS
|
|
386
|
+
}
|
|
387
|
+
)
|
|
388
|
+
if unknown_prm0:
|
|
389
|
+
report.warning(
|
|
390
|
+
"UNSUPPORTED_PRM0_VALUES",
|
|
391
|
+
"some compact piece-level property modifiers are not supported",
|
|
392
|
+
isprm=[f"0x{value:02X}" for value in unknown_prm0],
|
|
393
|
+
)
|
|
394
|
+
# Validate all complex references even if their property group
|
|
395
|
+
# is not implemented yet.
|
|
396
|
+
for piece in piece_table.pieces:
|
|
397
|
+
if piece.prm & 1 and piece.prm >> 1 >= len(prcs):
|
|
398
|
+
raise InvalidWordDocument(
|
|
399
|
+
f"piece Prm references PRC {piece.prm >> 1}, "
|
|
400
|
+
f"but CLX has {len(prcs)}"
|
|
401
|
+
)
|
|
402
|
+
return piece_table
|
|
403
|
+
raise InvalidWordDocument(f"CLX contains unknown clxt 0x{clxt:02X}")
|
|
404
|
+
except BinaryBoundsError as exc:
|
|
405
|
+
raise InvalidWordDocument(f"truncated CLX/Piece Table: {exc}") from exc
|
|
406
|
+
|
|
407
|
+
raise InvalidWordDocument("CLX does not contain a Pcdt/Piece Table")
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""MS-DOC PlcfSed, Sed, Sepx, and basic page-layout parsing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import replace
|
|
6
|
+
import struct
|
|
7
|
+
|
|
8
|
+
from ..diagnostics import ConversionReport, SourceLocation
|
|
9
|
+
from ..errors import InvalidWordDocument
|
|
10
|
+
from ..model import SectionBreakType, SectionProperties
|
|
11
|
+
from .sprm import PropertyModifier, parse_grpprl
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
_BREAK_TYPES = {
|
|
15
|
+
0x00: SectionBreakType.CONTINUOUS,
|
|
16
|
+
0x01: SectionBreakType.NEXT_COLUMN,
|
|
17
|
+
0x02: SectionBreakType.NEXT_PAGE,
|
|
18
|
+
0x03: SectionBreakType.EVEN_PAGE,
|
|
19
|
+
0x04: SectionBreakType.ODD_PAGE,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
_HEADER_DISTANCE_708_LCIDS = {
|
|
23
|
+
1026,
|
|
24
|
+
1027,
|
|
25
|
+
1029,
|
|
26
|
+
1030,
|
|
27
|
+
1035,
|
|
28
|
+
1038,
|
|
29
|
+
1039,
|
|
30
|
+
1043,
|
|
31
|
+
1044,
|
|
32
|
+
1045,
|
|
33
|
+
1048,
|
|
34
|
+
1051,
|
|
35
|
+
1055,
|
|
36
|
+
1058,
|
|
37
|
+
1059,
|
|
38
|
+
1060,
|
|
39
|
+
1061,
|
|
40
|
+
1067,
|
|
41
|
+
1068,
|
|
42
|
+
1069,
|
|
43
|
+
1078,
|
|
44
|
+
1079,
|
|
45
|
+
1087,
|
|
46
|
+
1088,
|
|
47
|
+
1089,
|
|
48
|
+
1092,
|
|
49
|
+
2074,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
_REQUIRED_MARGIN_SPRMS = {
|
|
53
|
+
0xB021: "left",
|
|
54
|
+
0xB022: "right",
|
|
55
|
+
0x9023: "top",
|
|
56
|
+
0x9024: "bottom",
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _default_header_distance(lid: int) -> int:
|
|
61
|
+
if lid == 1063:
|
|
62
|
+
return 567
|
|
63
|
+
if lid in _HEADER_DISTANCE_708_LCIDS:
|
|
64
|
+
return 708
|
|
65
|
+
return 720
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _u16(operand: bytes) -> int:
|
|
69
|
+
return struct.unpack("<H", operand)[0]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _i16(operand: bytes) -> int:
|
|
73
|
+
return struct.unpack("<h", operand)[0]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _apply_section_modifiers(
|
|
77
|
+
section: SectionProperties,
|
|
78
|
+
modifiers: tuple[PropertyModifier, ...],
|
|
79
|
+
) -> tuple[SectionProperties, set[int], set[int]]:
|
|
80
|
+
unsupported: set[int] = set()
|
|
81
|
+
seen: set[int] = set()
|
|
82
|
+
for modifier in modifiers:
|
|
83
|
+
opcode = modifier.opcode
|
|
84
|
+
operand = modifier.operand
|
|
85
|
+
seen.add(opcode)
|
|
86
|
+
if opcode == 0x3009: # sprmSBkc
|
|
87
|
+
break_type = _BREAK_TYPES.get(operand[0])
|
|
88
|
+
if break_type is None:
|
|
89
|
+
unsupported.add(opcode)
|
|
90
|
+
else:
|
|
91
|
+
section = replace(section, break_type=break_type)
|
|
92
|
+
elif opcode == 0x300A: # sprmSFTitlePage
|
|
93
|
+
if operand[0] not in (0x00, 0x01):
|
|
94
|
+
unsupported.add(opcode)
|
|
95
|
+
else:
|
|
96
|
+
section = replace(section, title_page=bool(operand[0]))
|
|
97
|
+
elif opcode == 0xB017: # sprmSDyaHdrTop
|
|
98
|
+
section = replace(section, header_distance_twips=_u16(operand))
|
|
99
|
+
elif opcode == 0xB018: # sprmSDyaHdrBottom
|
|
100
|
+
section = replace(section, footer_distance_twips=_u16(operand))
|
|
101
|
+
elif opcode == 0x301D: # sprmSBOrientation
|
|
102
|
+
orientation = {0x01: "portrait", 0x02: "landscape"}.get(operand[0])
|
|
103
|
+
if orientation is None:
|
|
104
|
+
unsupported.add(opcode)
|
|
105
|
+
else:
|
|
106
|
+
section = replace(section, orientation=orientation)
|
|
107
|
+
elif opcode == 0xB01F: # sprmSXaPage
|
|
108
|
+
width = _u16(operand)
|
|
109
|
+
if not 144 <= width <= 31680:
|
|
110
|
+
raise InvalidWordDocument(
|
|
111
|
+
f"section page width {width} is outside [144, 31680] twips"
|
|
112
|
+
)
|
|
113
|
+
section = replace(section, page_width_twips=width)
|
|
114
|
+
elif opcode == 0xB020: # sprmSYaPage
|
|
115
|
+
height = _u16(operand)
|
|
116
|
+
if not 144 <= height <= 31680:
|
|
117
|
+
raise InvalidWordDocument(
|
|
118
|
+
f"section page height {height} is outside [144, 31680] twips"
|
|
119
|
+
)
|
|
120
|
+
section = replace(section, page_height_twips=height)
|
|
121
|
+
elif opcode == 0xB021: # sprmSDxaLeft
|
|
122
|
+
section = replace(section, margin_left_twips=_u16(operand))
|
|
123
|
+
elif opcode == 0xB022: # sprmSDxaRight
|
|
124
|
+
section = replace(section, margin_right_twips=_u16(operand))
|
|
125
|
+
elif opcode == 0x9023: # sprmSDyaTop
|
|
126
|
+
margin = _i16(operand)
|
|
127
|
+
if not -31665 <= margin <= 31665:
|
|
128
|
+
raise InvalidWordDocument(
|
|
129
|
+
f"section top margin {margin} is outside [-31665, 31665] twips"
|
|
130
|
+
)
|
|
131
|
+
section = replace(section, margin_top_twips=margin)
|
|
132
|
+
elif opcode == 0x9024: # sprmSDyaBottom
|
|
133
|
+
margin = _i16(operand)
|
|
134
|
+
if not -31665 <= margin <= 31665:
|
|
135
|
+
raise InvalidWordDocument(
|
|
136
|
+
f"section bottom margin {margin} is outside [-31665, 31665] twips"
|
|
137
|
+
)
|
|
138
|
+
section = replace(section, margin_bottom_twips=margin)
|
|
139
|
+
elif opcode == 0xB025: # sprmSDzaGutter
|
|
140
|
+
section = replace(section, gutter_twips=_u16(operand))
|
|
141
|
+
else:
|
|
142
|
+
unsupported.add(opcode)
|
|
143
|
+
return section, unsupported, seen
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _read_sepx(
|
|
147
|
+
word_document: bytes,
|
|
148
|
+
fc_sepx: int,
|
|
149
|
+
*,
|
|
150
|
+
section_index: int,
|
|
151
|
+
) -> tuple[PropertyModifier, ...]:
|
|
152
|
+
if fc_sepx == -1:
|
|
153
|
+
return ()
|
|
154
|
+
if fc_sepx < 0 or fc_sepx > len(word_document) - 2:
|
|
155
|
+
raise InvalidWordDocument(
|
|
156
|
+
f"Sed {section_index} fcSepx {fc_sepx} points outside WordDocument"
|
|
157
|
+
)
|
|
158
|
+
byte_count = struct.unpack_from("<h", word_document, fc_sepx)[0]
|
|
159
|
+
if byte_count < 0:
|
|
160
|
+
raise InvalidWordDocument(
|
|
161
|
+
f"Sepx {section_index} has negative byte count {byte_count}"
|
|
162
|
+
)
|
|
163
|
+
end = fc_sepx + 2 + byte_count
|
|
164
|
+
if end > len(word_document):
|
|
165
|
+
raise InvalidWordDocument(
|
|
166
|
+
f"Sepx {section_index} range [{fc_sepx}, {end}) exceeds WordDocument"
|
|
167
|
+
)
|
|
168
|
+
return parse_grpprl(
|
|
169
|
+
word_document[fc_sepx + 2 : end],
|
|
170
|
+
label=f"Sepx {section_index}.grpprl",
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def read_sections(
|
|
175
|
+
table_stream: bytes,
|
|
176
|
+
word_document: bytes,
|
|
177
|
+
*,
|
|
178
|
+
offset: int,
|
|
179
|
+
size: int,
|
|
180
|
+
main_story_cp_count: int,
|
|
181
|
+
document_lid: int,
|
|
182
|
+
report: ConversionReport,
|
|
183
|
+
) -> tuple[SectionProperties, ...]:
|
|
184
|
+
"""Resolve the main-story section PLC into page-layout properties."""
|
|
185
|
+
|
|
186
|
+
if size == 0:
|
|
187
|
+
return ()
|
|
188
|
+
if offset < 0 or size < 0 or offset > len(table_stream) - size:
|
|
189
|
+
raise InvalidWordDocument(
|
|
190
|
+
f"PlcfSed range [{offset}, {offset + size}) exceeds Table stream"
|
|
191
|
+
)
|
|
192
|
+
data = table_stream[offset : offset + size]
|
|
193
|
+
if len(data) < 20 or (len(data) - 4) % 16:
|
|
194
|
+
raise InvalidWordDocument(
|
|
195
|
+
f"PlcfSed size {len(data)} does not match the 16*n+4 PLC layout"
|
|
196
|
+
)
|
|
197
|
+
section_count = (len(data) - 4) // 16
|
|
198
|
+
cps = struct.unpack_from(f"<{section_count + 1}I", data)
|
|
199
|
+
if cps[0] != 0:
|
|
200
|
+
raise InvalidWordDocument(f"PlcfSed starts at CP {cps[0]}, expected 0")
|
|
201
|
+
for previous, current in zip(cps, cps[1:]):
|
|
202
|
+
if current <= previous:
|
|
203
|
+
raise InvalidWordDocument("PlcfSed CP values are not increasing")
|
|
204
|
+
if cps[-1] < main_story_cp_count:
|
|
205
|
+
raise InvalidWordDocument(
|
|
206
|
+
f"PlcfSed ends at CP {cps[-1]} before main story CP {main_story_cp_count}"
|
|
207
|
+
)
|
|
208
|
+
if any(cp > main_story_cp_count for cp in cps[1:-1]):
|
|
209
|
+
raise InvalidWordDocument("PlcfSed has an internal boundary beyond the main story")
|
|
210
|
+
|
|
211
|
+
sed_offset = 4 * (section_count + 1)
|
|
212
|
+
default_header_distance = _default_header_distance(document_lid)
|
|
213
|
+
sections: list[SectionProperties] = []
|
|
214
|
+
unsupported: set[int] = set()
|
|
215
|
+
for index in range(section_count):
|
|
216
|
+
record_offset = sed_offset + index * 12
|
|
217
|
+
fc_sepx = struct.unpack_from("<i", data, record_offset + 2)[0]
|
|
218
|
+
modifiers = _read_sepx(
|
|
219
|
+
word_document,
|
|
220
|
+
fc_sepx,
|
|
221
|
+
section_index=index,
|
|
222
|
+
)
|
|
223
|
+
section = SectionProperties(
|
|
224
|
+
cp_start=cps[index],
|
|
225
|
+
cp_end=min(cps[index + 1], main_story_cp_count),
|
|
226
|
+
header_distance_twips=default_header_distance,
|
|
227
|
+
footer_distance_twips=default_header_distance,
|
|
228
|
+
)
|
|
229
|
+
section, section_unsupported, seen = _apply_section_modifiers(
|
|
230
|
+
section,
|
|
231
|
+
modifiers,
|
|
232
|
+
)
|
|
233
|
+
unsupported.update(section_unsupported)
|
|
234
|
+
missing_margins = [
|
|
235
|
+
name for opcode, name in _REQUIRED_MARGIN_SPRMS.items() if opcode not in seen
|
|
236
|
+
]
|
|
237
|
+
if missing_margins:
|
|
238
|
+
report.warning(
|
|
239
|
+
"SECTION_MARGIN_DEFAULTED",
|
|
240
|
+
"required or implementation-dependent DOC section margins were absent; 1 inch was used",
|
|
241
|
+
location=SourceLocation(
|
|
242
|
+
story="main",
|
|
243
|
+
cp_start=section.cp_start,
|
|
244
|
+
cp_end=section.cp_end,
|
|
245
|
+
stream="WordDocument" if fc_sepx >= 0 else "Table",
|
|
246
|
+
fc_start=fc_sepx if fc_sepx >= 0 else offset + record_offset,
|
|
247
|
+
),
|
|
248
|
+
section_index=index,
|
|
249
|
+
margins=missing_margins,
|
|
250
|
+
)
|
|
251
|
+
sections.append(section)
|
|
252
|
+
|
|
253
|
+
if unsupported:
|
|
254
|
+
report.warning(
|
|
255
|
+
"UNSUPPORTED_SECTION_SPRMS",
|
|
256
|
+
"some DOC section properties are not yet supported",
|
|
257
|
+
opcodes=[f"0x{value:04X}" for value in sorted(unsupported)],
|
|
258
|
+
)
|
|
259
|
+
return tuple(sections)
|