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,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)